Skip to content

PoC: Add bundle download cmd#5037

Open
sebrandon1 wants to merge 1 commit intocrc-org:mainfrom
sebrandon1:add_bundle_download_cmd
Open

PoC: Add bundle download cmd#5037
sebrandon1 wants to merge 1 commit intocrc-org:mainfrom
sebrandon1:add_bundle_download_cmd

Conversation

@sebrandon1
Copy link
Contributor

@sebrandon1 sebrandon1 commented Dec 2, 2025

Expanding on the already existing crc bundle command with some exciting new functionality. I wanted to be able to download/manipulate the bundles available either locally or on the mirror website from the CRC client. I also wanted to be able to download bundles for different architectures if needed (will be used in quick-ocp more than likely).

Example commands:

  • ./crc bundle download this is just the default command, and it'll download the bundle belonging to your crc release.
  • ./crc bundle download 4.19 -v this will download the latest available 4.19 bundle (similar to the way quick-ocp does it) where it will determine what the latest patch version is, and download it. You can also just supply an exact patch version that works as well. By default, it will check the downloaded bundle against the hash signature to make sure you have downloaded it correctly. Even thought crc won't be able to directly use the old bundles, its already been helpful for me to be able to download these bundles directly.
  • ./crc bundle download 4.19 amd64 -v will download a specific architecture version.
  • ./crc bundle download list will show you a list of all of the available versions of CRC/OCP available from the mirror website and whether or not you already have the bundle downloaded on your system already.
  • ./crc bundle download clear will clear all of your bundles out of your cache.
  • ./crc bundle download prune will clear all but the latest two patch versions belonging to a major version out of your cache. Like a watered down version of clear.

Real output:

bpalm at bpalm-mac in ~/Repositories/go/src/github.com/crc-org/crc on add_bundle_download_cmd [!?]
$ ./crc bundle download 4.19 -v
INFO Resolving latest version for 4.19...         
INFO Resolved version 4.19 to 4.19.13             
INFO Downloading bundle: crc_vfkit_4.19.13_arm64.crcbundle 
INFO Source: https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles/openshift/4.19.13/crc_vfkit_4.19.13_arm64.crcbundle 
INFO Destination: /Users/bpalm/.crc/cache         
INFO Verifying signature for 4.19.13...           
6.01 GiB / 6.01 GiB [-----------------------------------------------] 100.00% 7.73 MiB/s

Summary by CodeRabbit

  • New Features

    • Bundle command adds download, list, clear, and prune subcommands.
    • Download: select preset/version/arch, mirror-based resolution, signature-verified retrieval, and force overwrite.
    • List: shows available bundle versions and marks cached items.
    • Clear: removes cached bundles; Prune: deletes older bundles while keeping recent ones.
  • Tests

    • Manpage generation test updated to include the new bundle subcommands.

@openshift-ci
Copy link

openshift-ci bot commented Dec 2, 2025

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign evidolob for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci
Copy link

openshift-ci bot commented Dec 2, 2025

Hi @sebrandon1. Thanks for your PR.

I'm waiting for a github.com member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@coderabbitai
Copy link

coderabbitai bot commented Dec 2, 2025

Walkthrough

Adds four bundle subcommands (download, list, clear, prune); implements remote version discovery, partial-version resolution, signature-verified downloads into the machine cache, cache inspection/cleanup (list/clear/prune), and updates bundle naming and mirror constants; test expectations for generated manpages updated.

Changes

Cohort / File(s) Summary
Bundle command wiring
cmd/crc/cmd/bundle/bundle.go
Extended command description and registered new subcommands: download, list, clear, prune.
Download implementation
cmd/crc/cmd/bundle/download.go
New download subcommand: resolves partial versions, constructs bundle and signature URLs (using DefaultMirrorURL), verifies signatures to obtain SHA256, downloads bundles into machine cache, supports --force, preset and arch handling.
Listing & discovery
cmd/crc/cmd/bundle/list.go, cmd/crc/cmd/bundle/util.go
New list subcommand and helpers: fetchAvailableVersions parses mirror listings; resolveOpenShiftVersion resolves partial versions; isBundleCached marks cached entries.
Cache maintenance
cmd/crc/cmd/bundle/clear.go, cmd/crc/cmd/bundle/prune.go
New clear command removes .crcbundle files; prune keeps N newest (default 2) and deletes older bundles; both log actions and continue on per-file errors.
Constants / naming
pkg/crc/constants/constants.go
Added DefaultMirrorURL and BundleName(preset, version, arch string); adjusted BundleForPreset to use new naming (arch passed explicitly).
Tests / Manpages
cmd/crc/cmd/root_test.go
Updated manpage generation test to include crc-bundle-download.1, crc-bundle-list.1, crc-bundle-clear.1, crc-bundle-prune.1.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor User
    participant CLI as "crc CLI (bundle)"
    participant Mirror as "Mirror (HTTP)"
    participant Verifier as "GPG verifier"
    participant FS as "Local cache (filesystem)"

    User->>CLI: crc bundle download [version] [--force] [--preset] [--arch]
    CLI->>CLI: parse flags (preset, arch, force)
    alt partial version provided
        CLI->>Mirror: fetch available versions (fetchAvailableVersions)
        Mirror-->>CLI: HTML listing
        CLI->>CLI: resolve to concrete version (resolveOpenShiftVersion)
    end
    CLI->>Mirror: fetch signature (.sig)
    Mirror-->>CLI: signed payload
    CLI->>Verifier: verify signature, extract SHA256
    Verifier-->>CLI: verified hash
    CLI->>Mirror: download bundle using expected hash
    Mirror-->>CLI: bundle stream
    CLI->>FS: write bundle to machine cache
    FS-->>CLI: confirm write
    CLI->>User: success / error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped to the mirror, found names on display,
Checked sigs with a wiggle and carried one away,
Listed new versions, nudged the old to the bin,
Cleared crumbs from the cache with a hopeful grin,
Verified and snug — a rabbit's small win!

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is comprehensive and provides good context, but does not follow the provided template structure with sections like Type of change, Proposed changes, Testing, and Contribution Checklist. Consider following the repository's description template to include sections for Type of change, Proposed changes, Testing checklist, and platform testing confirmation.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and directly describes the main feature being added, though it uses "PoC" prefix which may indicate early-stage work.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
cmd/crc/cmd/bundle/download.go (3)

86-96: Consider making the prune retention count configurable.

The keep value is hardcoded to 2. Consider exposing this as a flag (e.g., --keep) to allow users to control how many bundles to retain.

 func getPruneCmd() *cobra.Command {
-	return &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "prune",
 		Short: "Prune old CRC bundles",
 		Long:  "Keep only the most recent bundles and delete older ones to save space.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// Default keep 2 most recent
-			return runPrune(2)
+			keep, _ := cmd.Flags().GetInt("keep")
+			return runPrune(keep)
 		},
 	}
+	cmd.Flags().IntP("keep", "k", 2, "Number of most recent bundles to keep")
+	return cmd
 }

253-261: Consider failing early when version resolution fails for partial versions.

When a partial version (e.g., 4.19) cannot be resolved, the code warns and continues with the original version string. This will likely result in a 404 error later since 4.19 isn't a valid bundle path. Consider failing early for better UX.

 	resolvedVersion, err := resolveOpenShiftVersion(preset, version, verbose)
 	if err != nil {
-		logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", version, err)
-	} else if resolvedVersion != version {
+		// Only warn if it looks like a full version that just wasn't found
+		partialVersionRegex := regexp.MustCompile(`^(\d+\.\d+)$`)
+		if partialVersionRegex.MatchString(version) {
+			return fmt.Errorf("could not resolve version %s: %w", version, err)
+		}
+		logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", version, err)
+	}
+	if resolvedVersion != "" && resolvedVersion != version {
 		if verbose {
 			logging.Infof("Resolved version %s to %s", version, resolvedVersion)
 		}
 		version = resolvedVersion
 	}

370-378: Consider adding context support for request cancellation.

The HTTP request doesn't use a context, which means long-running requests can't be cancelled by the user (e.g., via Ctrl+C). Consider accepting a context parameter.

-func fetchAvailableVersions(preset crcPreset.Preset) ([]*semver.Version, error) {
+func fetchAvailableVersions(ctx context.Context, preset crcPreset.Preset) ([]*semver.Version, error) {
 	baseURL := fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles/%s/", preset.String())
 
 	client := &http.Client{
 		Transport: httpproxy.HTTPTransport(),
 		Timeout:   10 * time.Second,
 	}
 
-	req, err := http.NewRequest("GET", baseURL, nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
 	if err != nil {
 		return nil, err
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ae41f68 and 2ee63db.

📒 Files selected for processing (3)
  • Makefile (1 hunks)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (16)
  • GitHub Check: build (macOS-13, 1.24)
  • GitHub Check: build (macOS-14, 1.24)
  • GitHub Check: build-qe (windows, amd64)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build-qe (darwin, arm64)
  • GitHub Check: verify-devcontainer
  • GitHub Check: Run OKD bundle with crc (1.24)
  • GitHub Check: build (ubuntu-22.04, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (macOS-14, 1.24)
  • GitHub Check: build (macOS-13, 1.24)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
🔇 Additional comments (7)
Makefile (1)

94-97: LGTM!

The new build target correctly mirrors the install target but uses go build instead of go install, allowing developers to build without installing. This is a useful addition for development workflows.

cmd/crc/cmd/bundle/bundle.go (1)

18-18: LGTM!

Clean integration of the new download subcommand, following the existing pattern for adding subcommands.

cmd/crc/cmd/bundle/download.go (5)

29-62: LGTM!

Well-structured command definition with appropriate flags and subcommands. The preset parsing with fallback to config is handled correctly.


173-214: LGTM!

The list command implementation is clean with appropriate version filtering and cache indication.


307-335: LGTM!

Solid signature verification implementation that properly validates the GPG signature before extracting the hash.


337-359: LGTM!

Bundle name construction correctly handles different presets and operating systems.


436-470: LGTM!

Version resolution logic correctly handles full versions, partial versions, and edge cases.

Comment on lines 154 to 157
// Sort by modification time, newest first
sort.Slice(bundleFiles, func(i, j int) bool {
infoI, _ := bundleFiles[i].Info()
infoJ, _ := bundleFiles[j].Info()
return infoI.ModTime().After(infoJ.ModTime())
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Potential nil pointer dereference when Info() fails.

If Info() returns an error (e.g., file deleted between ReadDir and Info call, or permission issue), the returned FileInfo is nil, causing a panic when accessing ModTime().

 	// Sort by modification time, newest first
 	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, _ := bundleFiles[i].Info()
-		infoJ, _ := bundleFiles[j].Info()
+		infoI, errI := bundleFiles[i].Info()
+		infoJ, errJ := bundleFiles[j].Info()
+		if errI != nil || errJ != nil {
+			// If we can't get info, treat as oldest (sort to end for pruning)
+			return errJ != nil && errI == nil
+		}
 		return infoI.ModTime().After(infoJ.ModTime())
 	})

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from 2ee63db to 45985b5 Compare December 2, 2025 21:02
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
cmd/crc/cmd/bundle/download.go (1)

154-159: Potential nil pointer dereference when Info() fails.

If Info() returns an error (e.g., file deleted between ReadDir and Info call), the returned FileInfo is nil, causing a panic when accessing ModTime().

Apply this diff to handle the error case:

 	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, _ := bundleFiles[i].Info()
-		infoJ, _ := bundleFiles[j].Info()
+		infoI, errI := bundleFiles[i].Info()
+		infoJ, errJ := bundleFiles[j].Info()
+		if errI != nil || errJ != nil {
+			return errJ != nil && errI == nil
+		}
 		return infoI.ModTime().After(infoJ.ModTime())
 	})
🧹 Nitpick comments (3)
cmd/crc/cmd/bundle/download.go (3)

86-96: Consider making the keep count configurable.

The hardcoded keep=2 value could be exposed as a flag for flexibility (e.g., --keep=N).

 func getPruneCmd() *cobra.Command {
-	return &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "prune",
 		Short: "Prune old CRC bundles",
 		Long:  "Keep only the most recent bundles and delete older ones to save space.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// Default keep 2 most recent
-			return runPrune(2)
+			keep, _ := cmd.Flags().GetInt("keep")
+			return runPrune(keep)
 		},
 	}
+	cmd.Flags().IntP("keep", "k", 2, "Number of most recent bundles to keep")
+	return cmd
 }

115-117: Prefer os.Remove for regular files.

os.RemoveAll is designed for recursive directory removal. For single files, os.Remove is more explicit and semantically correct.

-			if err := os.RemoveAll(filePath); err != nil {
+			if err := os.Remove(filePath); err != nil {

365-379: Consider propagating context for cancellation support.

The HTTP request doesn't use context, preventing graceful cancellation (e.g., via Ctrl+C). For CLI tools this is usually acceptable, but adding context would improve the user experience.

-func fetchAvailableVersions(preset crcPreset.Preset) ([]*semver.Version, error) {
+func fetchAvailableVersions(ctx context.Context, preset crcPreset.Preset) ([]*semver.Version, error) {
 	baseURL := fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles/%s/", preset.String())
 
 	client := &http.Client{
 		Transport: httpproxy.HTTPTransport(),
 		Timeout:   10 * time.Second,
 	}
 
-	req, err := http.NewRequest("GET", baseURL, nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee63db and 45985b5.

📒 Files selected for processing (4)
  • Makefile (1 hunks)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
  • cmd/crc/cmd/root_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • Makefile
  • cmd/crc/cmd/bundle/bundle.go
🧰 Additional context used
🧬 Code graph analysis (1)
cmd/crc/cmd/bundle/download.go (4)
pkg/download/download.go (1)
  • InMemory (101-126)
pkg/crc/gpg/gpg.go (1)
  • GetVerifiedClearsignedMsgV3 (41-68)
pkg/crc/constants/keys.go (1)
  • RedHatReleaseKey (22-49)
pkg/crc/network/httpproxy/proxy.go (1)
  • HTTPTransport (257-264)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: build (macOS-13, 1.24)
  • GitHub Check: build (macOS-14, 1.24)
  • GitHub Check: build (ubuntu-22.04, 1.24)
  • GitHub Check: build (macOS-14, 1.24)
  • GitHub Check: Run OKD bundle with crc (1.24)
  • GitHub Check: build (macOS-13, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: verify-devcontainer
🔇 Additional comments (7)
cmd/crc/cmd/root_test.go (1)

26-29: LGTM!

The test expectations correctly account for the four new manpage files generated by the bundle download subcommands.

cmd/crc/cmd/bundle/download.go (6)

29-62: LGTM!

The command setup is well-structured with appropriate flags and subcommand wiring. The preset validation via ParsePresetE ensures only valid presets are accepted.


173-214: LGTM!

The list implementation correctly handles both partial and full version filtering, with appropriate fallback parsing logic and cache status indication.


289-305: LGTM - Good security practice!

The implementation correctly enforces signature verification before download and fails fast if verification fails, preventing download of potentially tampered bundles.


307-335: LGTM!

The signature verification properly uses GPG verification with the Red Hat release key and correctly parses the sha256sum file format.


348-355: Cross-OS bundle downloads are not supported.

runtime.GOOS is used to determine the OS-specific bundle suffix (vfkit/libvirt/hyperv), meaning users cannot download bundles for a different OS than their current one. The architecture parameter allows cross-architecture downloads but not cross-OS.

If this limitation is intentional, consider documenting it in the command's long description. If cross-OS downloads should be supported, an --os flag could be added similar to --preset.


436-470: LGTM!

The version resolution logic correctly distinguishes between full versions (returned as-is), partial versions (resolved to latest patch), and other formats (passed through). Since fetchAvailableVersions returns versions sorted newest-first, the first matching major.minor will be the latest patch.

@albfan
Copy link
Contributor

albfan commented Dec 2, 2025

To me managing this makes lot of sense

@sebrandon1
Copy link
Contributor Author

@albfan Do you know if the CI is failing because of my changes or because of something else? It seems like a CI problem?

@redbeam
Copy link
Contributor

redbeam commented Dec 9, 2025

@sebrandon1 It shouldn't be related to your code as they have been failing for other PRs too. The OKD pipeline, for example, fails because of an expired certificate.

@sebrandon1
Copy link
Contributor Author

Thanks @redbeam! Let me know if there's anything you spot that needs changing.

@redbeam
Copy link
Contributor

redbeam commented Dec 10, 2025

/ok-to-test

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
cmd/crc/cmd/bundle/download.go (1)

152-157: Potential nil pointer dereference when Info() fails.

This issue was flagged in a previous review. If Info() returns an error, the returned FileInfo is nil, causing a panic when accessing ModTime().

 	// Sort by modification time, newest first
 	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, _ := bundleFiles[i].Info()
-		infoJ, _ := bundleFiles[j].Info()
+		infoI, errI := bundleFiles[i].Info()
+		infoJ, errJ := bundleFiles[j].Info()
+		if errI != nil || errJ != nil {
+			// If we can't get info, treat as oldest (sort to end for pruning)
+			return errJ != nil && errI == nil
+		}
 		return infoI.ModTime().After(infoJ.ModTime())
 	})
🧹 Nitpick comments (4)
cmd/crc/cmd/bundle/download.go (4)

84-94: Consider making the keep count configurable.

The hardcoded keep=2 value works, but users might want to retain more or fewer bundles depending on their use case.

 func getPruneCmd() *cobra.Command {
-	return &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "prune",
 		Short: "Prune old CRC bundles",
 		Long:  "Keep only the most recent bundles and delete older ones to save space.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// Default keep 2 most recent
-			return runPrune(2)
+			keep, _ := cmd.Flags().GetInt("keep")
+			return runPrune(keep)
 		},
 	}
+	cmd.Flags().IntP("keep", "k", 2, "Number of recent bundles to keep")
+	return cmd
 }

214-221: Cache check is not architecture-aware.

This function uses BundleForPreset which hardcodes runtime.GOARCH. Bundles downloaded with an explicit architecture argument won't show as cached in the list output. Consider adding an optional arch parameter or using BundleName directly.

-func isBundleCached(preset crcPreset.Preset, version string) bool {
-	bundleName := constants.BundleForPreset(preset, version)
+func isBundleCached(preset crcPreset.Preset, version string, arch string) bool {
+	bundleName := constants.BundleName(preset, version, arch)
 	bundlePath := filepath.Join(constants.MachineCacheDir, bundleName)
 	if _, err := os.Stat(bundlePath); err == nil {
 		return true
 	}
 	return false
 }

319-327: Consider more robust hash file parsing.

The current parsing assumes exact format "hash filename". Using strings.Fields would be more resilient to whitespace variations.

 	lines := strings.Split(verifiedHashes, "\n")
 	for _, line := range lines {
-		if strings.HasSuffix(line, bundleName) {
-			sha256sum := strings.TrimSuffix(line, "  "+bundleName)
-			return sha256sum, nil
+		fields := strings.Fields(line)
+		if len(fields) == 2 && fields[1] == bundleName {
+			return fields[0], nil
 		}
 	}

333-346: Consider using context for request cancellation.

The HTTP client has a timeout, but using http.NewRequestWithContext would allow callers to cancel long-running requests and integrate better with the rest of the codebase.

-func fetchAvailableVersions(preset crcPreset.Preset) ([]*semver.Version, error) {
+func fetchAvailableVersions(ctx context.Context, preset crcPreset.Preset) ([]*semver.Version, error) {
 	// Base URL for the preset
 	baseURL := fmt.Sprintf("https://mirror.openshift.com/pub/openshift-v4/clients/crc/bundles/%s/", preset.String())
 
 	client := &http.Client{
 		Transport: httpproxy.HTTPTransport(),
 		Timeout:   10 * time.Second,
 	}
 
-	req, err := http.NewRequest("GET", baseURL, nil)
+	req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
 	if err != nil {
 		return nil, err
 	}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 45985b5 and 5f22c48.

📒 Files selected for processing (5)
  • Makefile (1 hunks)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
  • cmd/crc/cmd/root_test.go (1 hunks)
  • pkg/crc/constants/constants.go (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • cmd/crc/cmd/bundle/bundle.go
  • cmd/crc/cmd/root_test.go
🧰 Additional context used
🧬 Code graph analysis (2)
pkg/crc/constants/constants.go (2)
pkg/crc/preset/preset.go (1)
  • Preset (9-9)
pkg/crc/config/settings.go (1)
  • Preset (33-33)
cmd/crc/cmd/bundle/download.go (8)
pkg/crc/preset/preset.go (1)
  • ParsePresetE (51-59)
pkg/crc/config/settings.go (1)
  • GetPreset (169-171)
pkg/crc/constants/constants.go (3)
  • GetDefaultBundlePath (129-131)
  • GetDefaultBundleDownloadURL (133-139)
  • BundleName (82-105)
pkg/crc/logging/logging.go (1)
  • Warnf (100-102)
pkg/download/download.go (1)
  • InMemory (101-126)
pkg/crc/gpg/gpg.go (1)
  • GetVerifiedClearsignedMsgV3 (41-68)
pkg/crc/constants/keys.go (1)
  • RedHatReleaseKey (22-49)
vendor/github.com/sebrandon1/grab/lib/request.go (1)
  • NewRequest (113-125)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: build-qe (windows, amd64)
  • GitHub Check: build (macOS-26, 1.24)
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build-qe (darwin, arm64)
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: verify-devcontainer
  • GitHub Check: build (macOS-15-intel, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: Run OKD bundle with crc (1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build (ubuntu-22.04, 1.24)
🔇 Additional comments (6)
Makefile (1)

94-97: LGTM!

The new build target provides a convenient way to compile without installing, using the same flags as install. This is useful for local development and CI workflows where installation isn't needed.

pkg/crc/constants/constants.go (1)

78-105: LGTM!

Good refactoring that extracts the architecture parameter while preserving backward compatibility through BundleForPreset. This enables multi-architecture bundle support as needed by the new download command.

cmd/crc/cmd/bundle/download.go (4)

29-60: LGTM!

Well-structured command setup with proper flag handling and preset resolution. The subcommand organization follows Cobra best practices.


171-212: LGTM!

The list implementation correctly handles version filtering and cache indication. The use of fmt.Printf for the actual version list (vs logging.Infof for informational messages) appropriately separates structured output from log messages.


223-297: Well-implemented download logic with proper security measures.

Good implementation with:

  • Cache-aware downloads with force override
  • Version resolution for partial versions
  • Mandatory signature verification before download
  • SHA256 integrity check during download

The separation between default bundle (using existing bundle.Download) and custom version downloads is appropriate.


404-436: LGTM!

Clean version resolution logic with appropriate early returns. Finding the first match from the sorted (newest-first) list correctly resolves partial versions like 4.19 to the latest patch 4.19.x.

Comment on lines 108 to 126
cleared := false
for _, file := range files {
if strings.HasSuffix(file.Name(), ".crcbundle") {
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Deleting %s", filePath)
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
}
cleared = true
}
}

if !cleared {
logging.Infof("No bundles found in %s", cacheDir)
} else {
logging.Infof("Cleared cached bundles in %s", cacheDir)
}
return nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Partial deletion failures are silently swallowed.

If some bundles fail to delete (e.g., permission issues), the function logs errors but returns nil, indicating success. Consider collecting errors and returning them, or at least returning a non-nil error when any deletion fails.

 	cleared := false
+	var lastErr error
 	for _, file := range files {
 		if strings.HasSuffix(file.Name(), ".crcbundle") {
 			filePath := filepath.Join(cacheDir, file.Name())
 			logging.Infof("Deleting %s", filePath)
 			if err := os.RemoveAll(filePath); err != nil {
 				logging.Errorf("Failed to remove %s: %v", filePath, err)
+				lastErr = err
+			} else {
+				cleared = true
 			}
-			cleared = true
 		}
 	}
 
 	if !cleared {
 		logging.Infof("No bundles found in %s", cacheDir)
 	} else {
 		logging.Infof("Cleared cached bundles in %s", cacheDir)
 	}
-	return nil
+	return lastErr
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cleared := false
for _, file := range files {
if strings.HasSuffix(file.Name(), ".crcbundle") {
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Deleting %s", filePath)
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
}
cleared = true
}
}
if !cleared {
logging.Infof("No bundles found in %s", cacheDir)
} else {
logging.Infof("Cleared cached bundles in %s", cacheDir)
}
return nil
}
cleared := false
var lastErr error
for _, file := range files {
if strings.HasSuffix(file.Name(), ".crcbundle") {
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Deleting %s", filePath)
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
lastErr = err
} else {
cleared = true
}
}
}
if !cleared {
logging.Infof("No bundles found in %s", cacheDir)
} else {
logging.Infof("Cleared cached bundles in %s", cacheDir)
}
return lastErr
}
🤖 Prompt for AI Agents
In cmd/crc/cmd/bundle/download.go around lines 108 to 126, the loop deletes
found .crcbundle files but swallows partial failures by only logging them and
still returning nil; change this to collect any deletion errors (e.g.,
accumulate errors in a slice or use a multi-error helper) and after the loop, if
any delete failed return a non-nil aggregated error (while preserving per-file
logging and the existing info logs), otherwise return nil; ensure the returned
error includes which file(s) failed and the underlying error(s) for easier
debugging.

@redbeam
Copy link
Contributor

redbeam commented Dec 11, 2025

@sebrandon1 Thanks for addressing my comments. If you could just squash the new commit into the previous one please :) Otherwise LGTM

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from 5f22c48 to 793e90c Compare December 11, 2025 17:42
@sebrandon1
Copy link
Contributor Author

Squashed ✅

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
cmd/crc/cmd/bundle/download.go (2)

96-126: runClear swallows deletion failures but still reports full success

If any os.RemoveAll call fails, the error is only logged and runClear still returns nil, potentially printing “Cleared cached bundles” even when some bundles remain. This can mislead users and makes troubleshooting harder. (This was already raised in a previous review.)

You can track whether any deletions failed and return a non‑nil error when they do, while keeping per‑file logging. For example:

 func runClear() error {
@@
-	cleared := false
+	cleared := false
+	var lastErr error
 	for _, file := range files {
 		if strings.HasSuffix(file.Name(), ".crcbundle") {
 			filePath := filepath.Join(cacheDir, file.Name())
 			logging.Infof("Deleting %s", filePath)
 			if err := os.RemoveAll(filePath); err != nil {
 				logging.Errorf("Failed to remove %s: %v", filePath, err)
+				lastErr = err
+				continue
 			}
 			cleared = true
 		}
 	}
@@
 	} else {
 		logging.Infof("Cleared cached bundles in %s", cacheDir)
 	}
-	return nil
+	return lastErr
 }

128-169: Fix potential panic in runPrune sort comparator and surface deletion failures

The sort comparator ignores errors from DirEntry.Info() and then calls ModTime() unconditionally. If Info() fails, FileInfo can be nil, causing a panic. This was already pointed out earlier and is still present. Additionally, like runClear, deletion failures are only logged and runPrune still returns nil.

You can make the sort and deletion logic safer, for example:

 	// Sort by modification time, newest first
 	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, _ := bundleFiles[i].Info()
-		infoJ, _ := bundleFiles[j].Info()
-		return infoI.ModTime().After(infoJ.ModTime())
+		infoI, errI := bundleFiles[i].Info()
+		infoJ, errJ := bundleFiles[j].Info()
+
+		switch {
+		case errI != nil && errJ != nil:
+			// Fallback to name ordering if both fail
+			return bundleFiles[i].Name() < bundleFiles[j].Name()
+		case errI != nil:
+			// Treat missing info as oldest
+			return false
+		case errJ != nil:
+			return true
+		default:
+			return infoI.ModTime().After(infoJ.ModTime())
+		}
 	})
@@
-	for i := keep; i < len(bundleFiles); i++ {
+	var lastErr error
+	for i := keep; i < len(bundleFiles); i++ {
 		file := bundleFiles[i]
 		filePath := filepath.Join(cacheDir, file.Name())
 		logging.Infof("Pruning old bundle: %s", file.Name())
 		if err := os.RemoveAll(filePath); err != nil {
 			logging.Errorf("Failed to remove %s: %v", filePath, err)
+			lastErr = err
 		}
 	}
 
-	return nil
+	return lastErr
🧹 Nitpick comments (5)
pkg/crc/constants/constants.go (1)

78-105: BundleName helper is a good extraction; OS is still hard-wired to runtime.GOOS

The refactor keeps existing behavior for BundleForPreset while enabling explicit arch selection via BundleName, which is what the new download workflow needs. If you ever need to construct bundle names for a non-local OS (e.g., generating docs or tooling for another platform), consider adding an OS parameter instead of relying on runtime.GOOS, but that’s not required for this PR.

cmd/crc/cmd/bundle/download.go (4)

29-94: Command wiring and subcommands look cohesive

The download command structure with list, clear, and prune subcommands is clear and consistent with the rest of the CLI. Hard‑wiring prune to keep 2 bundles is fine for now, but if you expect different retention policies, consider a --keep flag (defaulting to 2) to make this configurable without code changes.


171-221: list only marks bundles cached for the default arch; multi‑arch caches aren’t reflected

runList uses isBundleCached(preset, v.String()), and isBundleCached delegates to constants.BundleForPreset, which always uses runtime.GOARCH. If a user downloads a different architecture (e.g., arm64 on an amd64 host), that version will never be shown as “(cached)” even though a bundle file exists.

If multi‑arch usage is a target for this command, consider either:

  • Scanning the cache directory for any file matching the version regardless of arch (e.g., crc_*_<version>_*.crcbundle) and marking the version as cached if any match exists, or
  • Extending list to show per‑arch information explicitly.

As a smaller UX tweak, you might also want to warn when a provided list filter argument cannot be parsed as either Major.Minor or Major.Minor.Patch, instead of silently listing everything.


223-297: Download flow and version resolution look solid; minor logging nit

The control flow for default vs custom version, version resolution, and signature verification is coherent and aligns with the new BundleName helper. One small clarity improvement: the “Destination” debug log currently prints constants.MachineCacheDir, while the actual target is bundlePath / defaultBundlePath. Logging the full file path would be more precise when debugging user reports.

-	logging.Debugf("Destination: %s", constants.MachineCacheDir)
+	logging.Debugf("Destination: %s", bundlePath)

(and similarly for the default‑bundle branch).


299-327: Hash-line parsing assumes a very specific sha256sum format

getVerifiedHashForCustomVersion trims " "+bundleName from the end of the line to extract the hash. This works as long as the file uses exactly two spaces before the filename and no extra markers (like *), but it’s a bit brittle: any formatting change in sha256sum.txt will cause a failure to find the hash, even though the signature verifies.

You could make this more robust by splitting on whitespace and matching the filename field, e.g.:

 for _, line := range lines {
-	if strings.HasSuffix(line, bundleName) {
-		sha256sum := strings.TrimSuffix(line, "  "+bundleName)
-		return sha256sum, nil
-	}
+	fields := strings.Fields(line)
+	if len(fields) >= 2 && fields[len(fields)-1] == bundleName {
+		return fields[0], nil
+	}
 }

This keeps behavior the same but tolerates minor formatting changes in the checksum file.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f22c48 and 793e90c.

📒 Files selected for processing (5)
  • Makefile (1 hunks)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
  • cmd/crc/cmd/root_test.go (1 hunks)
  • pkg/crc/constants/constants.go (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • cmd/crc/cmd/root_test.go
  • cmd/crc/cmd/bundle/bundle.go
  • Makefile
🧰 Additional context used
🧬 Code graph analysis (1)
pkg/crc/constants/constants.go (2)
pkg/crc/preset/preset.go (1)
  • Preset (9-9)
pkg/crc/config/settings.go (1)
  • Preset (33-33)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (16)
  • GitHub Check: build (macOS-15-intel, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (macOS-26, 1.24)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build-qe (darwin, arm64)
  • GitHub Check: build-qe (windows, amd64)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: verify-devcontainer
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (ubuntu-22.04, 1.24)
  • GitHub Check: Run OKD bundle with crc (1.24)
🔇 Additional comments (2)
cmd/crc/cmd/bundle/download.go (2)

329-402: Version discovery and sorting logic look correct

fetchAvailableVersions uses a bounded‑timeout client, scrapes only x.y.z-shaped paths, deduplicates them, and returns versions sorted newest‑first, which pairs nicely with resolveOpenShiftVersion and the list command. No functional issues spotted here.


404-436: Partial-version resolution to latest patch is implemented correctly

resolveOpenShiftVersion cleanly handles full versions, Major.Minor shorthand, and “other” strings (tags) by returning them unchanged, while the partial‑version path walks the sorted versions to pick the latest matching patch. Error handling (mirror failure / no match) is clear and surfaces useful messages to runDownload.

Copy link
Contributor

@albfan albfan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a better description on commands added, you can split into commits for easy cherry-picking

Consider split commands into different files (now all is in download.go)

is not clear if users will expect crc bundle download prune or just crc bundle prune

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from 793e90c to a0cc564 Compare December 17, 2025 20:07
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
cmd/crc/cmd/bundle/clear.go (1)

36-46: Partial deletion failures are silently swallowed and incorrectly counted as cleared.

The cleared flag is set to true even when os.RemoveAll fails. This means:

  1. The function returns nil even when some deletions failed
  2. The success message is shown despite failures
 	cleared := false
+	var lastErr error
 	for _, file := range files {
 		if strings.HasSuffix(file.Name(), ".crcbundle") {
 			filePath := filepath.Join(cacheDir, file.Name())
 			logging.Infof("Deleting %s", filePath)
 			if err := os.RemoveAll(filePath); err != nil {
 				logging.Errorf("Failed to remove %s: %v", filePath, err)
+				lastErr = err
+			} else {
+				cleared = true
 			}
-			cleared = true
 		}
 	}
 
 	if !cleared {
 		logging.Infof("No bundles found in %s", cacheDir)
 	} else {
 		logging.Infof("Cleared cached bundles in %s", cacheDir)
 	}
-	return nil
+	return lastErr
cmd/crc/cmd/bundle/prune.go (1)

50-55: Potential nil pointer dereference when Info() fails.

If Info() returns an error (e.g., file deleted between ReadDir and Info call, or permission issue), the returned FileInfo is nil, causing a panic when accessing ModTime().

 	// Sort by modification time, newest first
 	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, _ := bundleFiles[i].Info()
-		infoJ, _ := bundleFiles[j].Info()
+		infoI, errI := bundleFiles[i].Info()
+		infoJ, errJ := bundleFiles[j].Info()
+		if errI != nil || errJ != nil {
+			// If we can't get info, treat as oldest (sort to end for pruning)
+			return errJ != nil && errI == nil
+		}
 		return infoI.ModTime().After(infoJ.ModTime())
 	})
🧹 Nitpick comments (6)
cmd/crc/cmd/bundle/list.go (1)

36-46: Consider informing the user when filter parsing fails.

When an argument is provided but cannot be parsed as a valid version (neither as partial nor full), the filter remains nil and all versions are displayed without any indication to the user that their filter was ignored.

 	var filter *semver.Version
 	if len(args) > 0 {
 		v, err := semver.NewVersion(args[0] + ".0") // Treat 4.19 as 4.19.0 for partial matching
 		if err == nil {
 			filter = v
 		} else {
 			// Try parsing as full version just in case
 			v, err = semver.NewVersion(args[0])
 			if err == nil {
 				filter = v
+			} else {
+				logging.Warnf("Invalid version filter %q, showing all versions", args[0])
 			}
 		}
 	}
cmd/crc/cmd/bundle/prune.go (1)

14-24: Consider making keep configurable via a flag.

The keep value is hardcoded to 2. Users may want to retain a different number of bundles.

 func getPruneCmd() *cobra.Command {
-	return &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "prune",
 		Short: "Prune old CRC bundles",
 		Long:  "Keep only the most recent bundles and delete older ones to save space.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// Default keep 2 most recent
-			return runPrune(2)
+			keep, _ := cmd.Flags().GetInt("keep")
+			return runPrune(keep)
 		},
 	}
+	cmd.Flags().IntP("keep", "k", 2, "Number of most recent bundles to keep")
+	return cmd
 }
cmd/crc/cmd/bundle/download.go (1)

148-154: Hash parsing may fail silently if format differs.

The hash extraction assumes the sha256sum format with exactly two spaces between hash and filename ("hash filename"). If the format varies (e.g., single space, or * prefix for binary mode), the parsing will fail to match or extract incorrectly.

 	lines := strings.Split(verifiedHashes, "\n")
 	for _, line := range lines {
-		if strings.HasSuffix(line, bundleName) {
-			sha256sum := strings.TrimSuffix(line, "  "+bundleName)
-			return sha256sum, nil
+		// sha256sum format: "hash  filename" or "hash *filename" (binary mode)
+		line = strings.TrimSpace(line)
+		if strings.HasSuffix(line, bundleName) || strings.HasSuffix(line, "*"+bundleName) {
+			fields := strings.Fields(line)
+			if len(fields) >= 2 {
+				return fields[0], nil
+			}
 		}
 	}
cmd/crc/cmd/bundle/util.go (3)

26-26: Consider extracting regex patterns and making timeout configurable.

Optional improvements for maintainability:

  1. Extract the regex patterns (lines 50, 73) as package-level constants for easier testing and maintenance.
  2. Consider making the 10-second timeout configurable via a constant or parameter for users with slow network connections.

Example refactor:

+const (
+	mirrorFetchTimeout = 10 * time.Second
+	versionHrefPattern = `href=["']?\.?/?(\d+\.\d+\.\d+)/?["']?`
+	versionSimplePattern = `>(\d+\.\d+\.\d+)/?<`
+)
+
 func fetchAvailableVersions(preset crcPreset.Preset) ([]*semver.Version, error) {
 	// Base URL for the preset
 	baseURL := fmt.Sprintf("%s/%s/", constants.DefaultMirrorURL, preset.String())
 
 	client := &http.Client{
 		Transport: httpproxy.HTTPTransport(),
-		Timeout:   10 * time.Second,
+		Timeout:   mirrorFetchTimeout,
 	}
 
 	// ... request handling ...
 
 	// Parse the HTML directory listing to find version directories
-	versionRegex := regexp.MustCompile(`href=["']?\.?/?(\d+\.\d+\.\d+)/?["']?`)
+	versionRegex := regexp.MustCompile(versionHrefPattern)
 
 	// ... matching logic ...
 
 	// If regex failed, try a simpler one for directory names in text
 	if len(versions) == 0 {
-		simpleRegex := regexp.MustCompile(`>(\d+\.\d+\.\d+)/?<`)
+		simpleRegex := regexp.MustCompile(versionSimplePattern)

Also applies to: 50-50, 73-73


103-106: Non-standard version formats are passed through without validation.

When the input doesn't match Major.Minor.Patch or Major.Minor patterns, the function returns it as-is (line 105). This could lead to errors downstream if users provide malformed versions like "4.x", "latest", or "4.19-rc1".

Consider validating these cases or documenting that arbitrary strings are accepted (e.g., for tags).

Apply this diff to add validation for common edge cases:

 	// If not Major.Minor, return as is (could be a tag or other format user intends)
 	partialVersionRegex := regexp.MustCompile(`^(\d+\.\d+)$`)
 	if !partialVersionRegex.MatchString(inputVersion) {
-		return inputVersion, nil
+		// Check if it's at least a valid-looking tag/version string
+		if matched, _ := regexp.MatchString(`^[\w][\w.-]*$`, inputVersion); !matched {
+			return "", fmt.Errorf("invalid version format: %s", inputVersion)
+		}
+		logging.Debugf("Using version string as-is: %s", inputVersion)
+		return inputVersion, nil
 	}

Alternatively, document in a comment that arbitrary version strings are accepted for flexibility with tags.


129-136: Improve error handling to distinguish between "not cached" and other errors.

The function returns false for any os.Stat error, which masks issues like permission denied or corrupted filesystem paths. This could make debugging difficult if cache directory permissions are incorrect.

Apply this diff to be more explicit about error types:

+	"errors"
+
 func isBundleCached(preset crcPreset.Preset, version string) bool {
 	bundleName := constants.BundleForPreset(preset, version)
 	bundlePath := filepath.Join(constants.MachineCacheDir, bundleName)
-	if _, err := os.Stat(bundlePath); err == nil {
-		return true
+	_, err := os.Stat(bundlePath)
+	if err == nil {
+		return true
+	}
+	if errors.Is(err, os.ErrNotExist) {
+		return false
 	}
+	// Log unexpected errors but treat as not cached
+	logging.Debugf("Error checking bundle cache: %v", err)
 	return false
 }
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 793e90c and a0cc564.

📒 Files selected for processing (8)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/clear.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
  • cmd/crc/cmd/bundle/list.go (1 hunks)
  • cmd/crc/cmd/bundle/prune.go (1 hunks)
  • cmd/crc/cmd/bundle/util.go (1 hunks)
  • cmd/crc/cmd/root_test.go (1 hunks)
  • pkg/crc/constants/constants.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
cmd/crc/cmd/bundle/list.go (2)
pkg/crc/config/settings.go (1)
  • GetPreset (147-149)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
cmd/crc/cmd/bundle/download.go (6)
pkg/crc/preset/preset.go (1)
  • ParsePresetE (51-59)
pkg/crc/constants/constants.go (5)
  • GetDefaultBundlePath (135-137)
  • GetDefaultBundleDownloadURL (139-145)
  • BundleName (88-111)
  • MachineCacheDir (128-128)
  • DefaultMirrorURL (33-33)
pkg/crc/logging/logging.go (3)
  • Infof (92-94)
  • Debugf (124-126)
  • Warnf (100-102)
pkg/download/download.go (1)
  • InMemory (101-126)
pkg/crc/gpg/gpg.go (1)
  • GetVerifiedClearsignedMsgV3 (41-68)
pkg/crc/constants/keys.go (1)
  • RedHatReleaseKey (22-49)
cmd/crc/cmd/bundle/prune.go (2)
pkg/crc/constants/constants.go (1)
  • MachineCacheDir (128-128)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (16)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build-qe (darwin, arm64)
  • GitHub Check: build-qe (windows, amd64)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build (macOS-26, 1.24)
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (ubuntu-22.04, 1.24)
  • GitHub Check: verify-devcontainer
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: build (macOS-15-intel, 1.24)
  • GitHub Check: Run OKD bundle with crc (1.24)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
🔇 Additional comments (9)
cmd/crc/cmd/bundle/bundle.go (1)

12-21: LGTM!

The new subcommands are wired correctly following the existing pattern used for getGenerateCmd. The updated long description accurately reflects the expanded functionality.

cmd/crc/cmd/bundle/list.go (1)

12-21: LGTM!

The command definition is clean with appropriate usage documentation.

pkg/crc/constants/constants.go (2)

88-111: LGTM!

The BundleName function correctly separates the OS-specific hypervisor suffix (derived from runtime.GOOS) from the architecture parameter, enabling cross-architecture bundle downloads while maintaining the correct hypervisor naming for the current platform.


33-34: LGTM!

Extracting DefaultMirrorURL as a separate constant improves reusability and makes the URL construction in DefaultBundleURLBase clearer.

cmd/crc/cmd/bundle/download.go (3)

23-50: LGTM!

The command setup with --force and --preset flags is well-structured. The preset parsing with proper error handling is good practice.


74-84: LGTM!

Good fallback behavior when version resolution fails - logs a warning and continues with the original version string rather than failing outright.


67-71: Remove incorrect concern about verification parameter.

The third parameter is enableBundleQuayFallback, not enableBundleVerification. Setting it to false prevents falling back to quay if the mirror download fails, which is the correct behavior for default bundles. The comment accurately describes that verification is handled internally—downloadDefault calls getDefaultBundleVerifiedHash, which verifies the GPG signature on the hash file, and then uses that verified hash during download.

Likely an incorrect or invalid review comment.

cmd/crc/cmd/bundle/util.go (2)

1-18: LGTM!

The package declaration and imports are well-organized and appropriate for the bundle management utilities.


22-22: Primary regex is dead code; fallback regex successfully extracts versions but both remain fragile if mirror HTML format changes.

The function uses regex to parse the mirror's HTML directory listing. The primary regex pattern (line 50) returns no matches against the current mirror HTML—which wraps versions in <span class="name">VERSION</span> tags. The fallback regex (line 73) correctly extracts all 35+ available versions from the >VERSION< pattern. While the fallback currently works reliably, both patterns are fragile and would fail if the mirror changes its HTML structure. The primary regex should be removed as it serves no purpose; the fallback alone should be retained but documented as the only extraction pattern.

@albfan
Copy link
Contributor

albfan commented Dec 18, 2025

See the mileage for reorder and split this PR is not important, don't add commits with "split, comments" that's the internal history for this PR, the final commits needs to reflect what each commit is doing:

  • Adding build target
  • crc download
  • crc prune
  • crc clear
    ...

Some new subcommands will be easier to approve than others, helping to merge all this.

That requires squash all the commits and split changes again

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from a0cc564 to d09a477 Compare December 18, 2025 16:00
@sebrandon1
Copy link
Contributor Author

Okay I have squashed my commits and I have removed the build Make path that I will reopen in another PR.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
cmd/crc/cmd/root_test.go (1)

26-29: Critical: Manpage test expectations still don't match the command structure.

Despite the past review comment being marked as addressed, the issue persists. These manpage names imply clear, list, and prune are subcommands of download (e.g., crc bundle download clear). However, in bundle.go (lines 18-21), these commands are added as direct subcommands of bundle via bundleCmd.AddCommand(), making them peers of download rather than its children.

The correct manpage names should be:

  • crc-bundle-clear.1 (not crc-bundle-download-clear.1)
  • crc-bundle-list.1 (not crc-bundle-download-list.1)
  • crc-bundle-prune.1 (not crc-bundle-download-prune.1)
  • crc-bundle-download.1 ✓ (this one is correct)
🔎 Apply this diff to fix the manpage expectations:
 	assert.ElementsMatch(t, []string{
-		"crc-bundle-download-clear.1",
-		"crc-bundle-download-list.1",
-		"crc-bundle-download-prune.1",
+		"crc-bundle-clear.1",
+		"crc-bundle-list.1",
+		"crc-bundle-prune.1",
 		"crc-bundle-download.1",
🧹 Nitpick comments (2)
cmd/crc/cmd/bundle/util.go (2)

24-27: Consider increasing the HTTP timeout for slower connections.

The 10-second timeout might be insufficient for users on slow or unstable network connections, potentially causing false negatives when fetching version listings from the mirror.

🔎 Consider increasing the timeout:
 	client := &http.Client{
 		Transport: httpproxy.HTTPTransport(),
-		Timeout:   10 * time.Second,
+		Timeout:   30 * time.Second,
 	}

49-88: Replace fragile regex HTML parsing with a proper HTML parser.

The code parses HTML directory listings from mirror.openshift.com using regex patterns, which is fragile and vulnerable to format changes. Mirror.openshift.com provides directory listings in HTML format, not a structured API. While a fallback regex is provided, both patterns could fail if the HTML structure changes.

Consider using Goquery, a popular HTML parsing library for Go with jQuery-like syntax that performs HTML document traversals and selections efficiently using CSS selectors, or Go's standard net/html package for parsing and manipulating HTML documents in a structured manner.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a0cc564 and d09a477.

📒 Files selected for processing (8)
  • cmd/crc/cmd/bundle/bundle.go (1 hunks)
  • cmd/crc/cmd/bundle/clear.go (1 hunks)
  • cmd/crc/cmd/bundle/download.go (1 hunks)
  • cmd/crc/cmd/bundle/list.go (1 hunks)
  • cmd/crc/cmd/bundle/prune.go (1 hunks)
  • cmd/crc/cmd/bundle/util.go (1 hunks)
  • cmd/crc/cmd/root_test.go (1 hunks)
  • pkg/crc/constants/constants.go (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • cmd/crc/cmd/bundle/clear.go
  • cmd/crc/cmd/bundle/download.go
  • cmd/crc/cmd/bundle/prune.go
🧰 Additional context used
🧬 Code graph analysis (3)
pkg/crc/constants/constants.go (2)
pkg/crc/config/settings.go (1)
  • Preset (32-32)
pkg/crc/preset/preset.go (1)
  • Preset (9-9)
cmd/crc/cmd/bundle/util.go (3)
pkg/crc/constants/constants.go (3)
  • DefaultMirrorURL (33-33)
  • BundleForPreset (84-86)
  • MachineCacheDir (128-128)
pkg/crc/network/httpproxy/proxy.go (1)
  • HTTPTransport (257-264)
pkg/crc/logging/logging.go (1)
  • Debugf (124-126)
cmd/crc/cmd/bundle/list.go (2)
pkg/crc/config/settings.go (1)
  • GetPreset (147-149)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
🔇 Additional comments (7)
cmd/crc/cmd/bundle/bundle.go (2)

12-12: LGTM!

The updated long description accurately reflects the new bundle management capabilities.


18-21: LGTM!

The four new subcommands are properly wired as direct children of the bundle command, expanding the CLI's bundle management capabilities.

cmd/crc/cmd/bundle/list.go (1)

57-61: LGTM!

The cache status annotation provides helpful user feedback, clearly indicating which bundles are already downloaded.

pkg/crc/constants/constants.go (2)

33-34: LGTM!

Centralizing the mirror URL as DefaultMirrorURL and deriving DefaultBundleURLBase from it improves maintainability and makes it easier to change the mirror location in the future.


84-111: LGTM!

The refactoring to introduce BundleName with an explicit arch parameter enables cross-architecture bundle name generation while maintaining backward compatibility through BundleForPreset, which delegates to BundleName with runtime.GOARCH.

cmd/crc/cmd/bundle/util.go (2)

95-127: Verify the fallback behavior for non-standard version formats.

Lines 103-106 return the input version as-is if it doesn't match the Major.Minor pattern. This means inputs like "latest", "4.19.x", or other non-standard formats are passed through without validation. While this might be intentional to support tags or other formats, it could lead to confusing errors downstream when attempting to construct URLs or filenames.

Consider whether explicit validation and error handling for unsupported formats would improve the user experience.


129-136: LGTM!

The cache check is straightforward and correctly uses os.Stat to determine file existence.

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from d09a477 to 779e3b2 Compare January 6, 2026 16:55
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI Agents
In @cmd/crc/cmd/bundle/clear.go:
- Around line 36-46: The loop in clear.go sets cleared = true even when
os.RemoveAll fails and still returns nil; change the logic so that cleared is
only set to true after a successful os.RemoveAll, and make the function return
an error when any deletion fails (or aggregate errors) instead of swallowing
failures. Specifically, in the for loop over files (check for
strings.HasSuffix(file.Name(), ".crcbundle")), move cleared = true to after the
os.RemoveAll succeeds, and on error from os.RemoveAll collect or return that
error (e.g., return fmt.Errorf or append to a slice and return a combined error)
so callers can detect failures. Ensure logging remains but do not treat logged
failures as success.
🧹 Nitpick comments (3)
cmd/crc/cmd/bundle/prune.go (1)

14-23: Consider exposing keep as a CLI flag.

The keep parameter is hardcoded to 2. For flexibility, consider adding a --keep flag so users can specify how many bundles to retain.

🔎 Example implementation
 func getPruneCmd() *cobra.Command {
-	return &cobra.Command{
+	cmd := &cobra.Command{
 		Use:   "prune",
 		Short: "Prune old CRC bundles",
 		Long:  "Keep only the most recent bundles and delete older ones to save space.",
 		RunE: func(cmd *cobra.Command, args []string) error {
-			// Default keep 2 most recent
-			return runPrune(2)
+			keep, _ := cmd.Flags().GetInt("keep")
+			return runPrune(keep)
 		},
 	}
+	cmd.Flags().IntP("keep", "k", 2, "Number of most recent bundles to keep")
+	return cmd
 }
cmd/crc/cmd/bundle/download.go (2)

28-30: Ignoring flag retrieval errors may hide configuration issues.

The errors from GetBool and GetString are discarded. While these typically don't fail for registered flags, silently ignoring errors can hide misconfigurations during development or refactoring.

🔎 Consider logging or handling errors
 		RunE: func(cmd *cobra.Command, args []string) error {
-			force, _ := cmd.Flags().GetBool("force")
-			presetStr, _ := cmd.Flags().GetString("preset")
+			force, err := cmd.Flags().GetBool("force")
+			if err != nil {
+				return fmt.Errorf("failed to get force flag: %w", err)
+			}
+			presetStr, err := cmd.Flags().GetString("preset")
+			if err != nil {
+				return fmt.Errorf("failed to get preset flag: %w", err)
+			}

86-89: Consider validating the architecture argument.

The architecture value from args[1] is used directly without validation. While an invalid value will result in a download failure (404), validating against known architectures (e.g., amd64, arm64) would provide a clearer error message.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d09a477 and 779e3b2.

📒 Files selected for processing (8)
  • cmd/crc/cmd/bundle/bundle.go
  • cmd/crc/cmd/bundle/clear.go
  • cmd/crc/cmd/bundle/download.go
  • cmd/crc/cmd/bundle/list.go
  • cmd/crc/cmd/bundle/prune.go
  • cmd/crc/cmd/bundle/util.go
  • cmd/crc/cmd/root_test.go
  • pkg/crc/constants/constants.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • cmd/crc/cmd/bundle/util.go
  • cmd/crc/cmd/root_test.go
  • pkg/crc/constants/constants.go
🧰 Additional context used
🧬 Code graph analysis (2)
cmd/crc/cmd/bundle/list.go (2)
pkg/crc/config/settings.go (1)
  • GetPreset (147-149)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
cmd/crc/cmd/bundle/clear.go (2)
pkg/crc/constants/constants.go (1)
  • MachineCacheDir (128-128)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: verify-devcontainer
  • GitHub Check: build-qe (linux, arm64)
  • GitHub Check: build (ubuntu-22.04, 1.24)
  • GitHub Check: build-qe (darwin, arm64)
  • GitHub Check: build-qe (windows, amd64)
  • GitHub Check: build-qe (linux, amd64)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
  • GitHub Check: build (macOS-26, 1.24)
  • GitHub Check: build-installer (windows-2022, 1.24)
  • GitHub Check: build-qe (darwin, amd64)
  • GitHub Check: build (macOS-15-intel, 1.24)
  • GitHub Check: build (macOS-15, 1.24)
  • GitHub Check: build (windows-2022, 1.24)
  • GitHub Check: Run OKD bundle with crc (1.24)
  • GitHub Check: build (ubuntu-latest, 1.24)
🔇 Additional comments (4)
cmd/crc/cmd/bundle/bundle.go (1)

17-21: LGTM!

The new subcommands are wired consistently with the existing getGenerateCmd pattern. The updated Long description accurately reflects the expanded functionality.

cmd/crc/cmd/bundle/list.go (1)

49-62: LGTM!

The output pattern of logging the header via logging.Infof (stderr) and printing versions via fmt.Printf (stdout) is appropriate for CLI tools, enabling clean piping of version data.

cmd/crc/cmd/bundle/download.go (2)

128-156: Verification logic looks sound.

The GPG signature verification using RedHatReleaseKey and hash extraction follow the standard sha256sum file format correctly. The HasSuffix check is appropriate since bundle names are specific enough to avoid false matches.


69-71: The default bundle download properly handles signature verification. The fourth parameter is enableBundleQuayFallback (not related to verification), and verification happens through two mechanisms:

  1. GPG signature verification of the signed hash file (via getVerifiedHash using RedHatReleaseKey)
  2. SHA256 verification of the downloaded bundle content (via the Download function with the verified hash)

The comment on line 69 is accurate.

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch 2 times, most recently from 132bd85 to 3f865b6 Compare January 6, 2026 17:28
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI Agents
In @cmd/crc/cmd/bundle/util.go:
- Around line 129-136: isBundleCached currently uses constants.BundleForPreset
which calls BundleName with runtime.GOARCH, so it will miss cached bundles for
other architectures; change isBundleCached(preset crcPreset.Preset, version
string) to isBundleCached(preset crcPreset.Preset, version, arch string) and
build the bundle filename with constants.BundleName(preset, version, arch) (or
the appropriate signature that accepts arch) before checking
filepath.Join(constants.MachineCacheDir, bundleName) and os.Stat; update all
callers of isBundleCached to pass the requested arch.
🧹 Nitpick comments (2)
cmd/crc/cmd/bundle/util.go (2)

20-93: Robust implementation with good fallback logic.

The function properly handles proxy configuration, timeout, error cases, and has a fallback regex when the primary pattern fails. The deduplication and sorting logic is correct.

Consider adding a debug log when falling back to the simpler regex to aid troubleshooting:

🔎 Optional enhancement
 	// If regex failed, try a simpler one for directory names in text
 	if len(versions) == 0 {
+		logging.Debugf("Primary regex found no versions, trying fallback pattern")
 		simpleRegex := regexp.MustCompile(`>(\d+\.\d+\.\d+)/?<`)

95-127: Function name is misleading.

The function is named resolveOpenShiftVersion but it works for all presets (OpenShift, OKD, Microshift), not just OpenShift. Consider renaming to resolveVersion or resolveBundleVersion for clarity.

🔎 Suggested rename
-func resolveOpenShiftVersion(preset crcPreset.Preset, inputVersion string) (string, error) {
+func resolveVersion(preset crcPreset.Preset, inputVersion string) (string, error) {
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 132bd85 and 3f865b6.

📒 Files selected for processing (8)
  • cmd/crc/cmd/bundle/bundle.go
  • cmd/crc/cmd/bundle/clear.go
  • cmd/crc/cmd/bundle/download.go
  • cmd/crc/cmd/bundle/list.go
  • cmd/crc/cmd/bundle/prune.go
  • cmd/crc/cmd/bundle/util.go
  • cmd/crc/cmd/root_test.go
  • pkg/crc/constants/constants.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • cmd/crc/cmd/bundle/download.go
  • cmd/crc/cmd/root_test.go
  • cmd/crc/cmd/bundle/clear.go
  • cmd/crc/cmd/bundle/list.go
  • cmd/crc/cmd/bundle/prune.go
🧰 Additional context used
🧬 Code graph analysis (1)
cmd/crc/cmd/bundle/util.go (3)
pkg/crc/constants/constants.go (3)
  • DefaultMirrorURL (33-33)
  • BundleForPreset (84-86)
  • MachineCacheDir (128-128)
pkg/crc/network/httpproxy/proxy.go (1)
  • HTTPTransport (257-264)
pkg/crc/logging/logging.go (1)
  • Debugf (124-126)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build-qe (darwin, arm64)
🔇 Additional comments (3)
pkg/crc/constants/constants.go (2)

33-34: Good refactoring to centralize the mirror URL.

Extracting DefaultMirrorURL as a constant and reusing it in DefaultBundleURLBase improves maintainability.


84-111: Clarify the design intent: arch parameter vs. runtime.GOOS usage.

The new BundleName function accepts an explicit arch parameter, suggesting it can generate bundle names for arbitrary architectures. However, it still uses runtime.GOOS at line 100 to determine OS-specific hypervisor suffixes (_vfkit, _libvirt, _hyperv). This creates an asymmetry:

  • The architecture is parameterized (allowing cross-arch name generation)
  • The OS remains fixed to the current runtime environment

This could be confusing for callers expecting to generate bundle names for different OS/arch combinations. Please clarify whether:

  1. This is intentional (bundles are always OS-specific to the current runtime, but arch can vary)
  2. The os should also be parameterized for consistency
  3. Documentation should be added explaining this behavior

Consider adding a function comment documenting this behavior:

// BundleName generates a bundle filename for the given preset, version, and architecture.
// The hypervisor suffix (_vfkit, _libvirt, _hyperv) is determined by runtime.GOOS,
// as bundles are OS-specific but may target different architectures.
func BundleName(preset crcpreset.Preset, version string, arch string) string {
cmd/crc/cmd/bundle/bundle.go (1)

12-12: LGTM! Clean integration of new subcommands.

The updated description accurately reflects the expanded functionality, and the new subcommands follow the existing pattern used by getGenerateCmd.

Also applies to: 18-21

Comment on lines 129 to 136
func isBundleCached(preset crcPreset.Preset, version string) bool {
bundleName := constants.BundleForPreset(preset, version)
bundlePath := filepath.Join(constants.MachineCacheDir, bundleName)
if _, err := os.Stat(bundlePath); err == nil {
return true
}
return false
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache check may not work correctly for cross-architecture bundles.

The function uses constants.BundleForPreset (line 130), which internally calls BundleName with runtime.GOARCH. This means isBundleCached can only check for bundles matching the current system's architecture.

According to the PR objectives, the feature supports downloading bundles for specific architectures: crc bundle download <version> <arch>. If users download a bundle for a different architecture (e.g., downloading arm64 bundles on an amd64 system), this function will not correctly detect them as cached.

Consider adding an architecture parameter or using constants.BundleName with an explicit arch argument:

🔎 Proposed fix
-func isBundleCached(preset crcPreset.Preset, version string) bool {
-	bundleName := constants.BundleForPreset(preset, version)
+func isBundleCached(preset crcPreset.Preset, version string, arch string) bool {
+	bundleName := constants.BundleName(preset, version, arch)
 	bundlePath := filepath.Join(constants.MachineCacheDir, bundleName)
 	if _, err := os.Stat(bundlePath); err == nil {
 		return true
 	}
 	return false
 }

Then update all callers to pass the appropriate architecture parameter.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @cmd/crc/cmd/bundle/util.go around lines 129 - 136, isBundleCached currently
uses constants.BundleForPreset which calls BundleName with runtime.GOARCH, so it
will miss cached bundles for other architectures; change isBundleCached(preset
crcPreset.Preset, version string) to isBundleCached(preset crcPreset.Preset,
version, arch string) and build the bundle filename with
constants.BundleName(preset, version, arch) (or the appropriate signature that
accepts arch) before checking filepath.Join(constants.MachineCacheDir,
bundleName) and os.Stat; update all callers of isBundleCached to pass the
requested arch.

@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from 3f865b6 to 72c1c46 Compare January 6, 2026 18:05
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
cmd/crc/cmd/bundle/download.go (1)

148-155: Hash extraction could be more robust.

The current parsing assumes the exact format hash filename with two spaces. Consider using strings.Fields to split on whitespace, which handles varying whitespace and validates that exactly two fields exist.

🔎 Proposed fix
 	lines := strings.Split(verifiedHashes, "\n")
 	for _, line := range lines {
-		if strings.HasSuffix(line, bundleName) {
-			sha256sum := strings.TrimSuffix(line, "  "+bundleName)
-			return sha256sum, nil
+		fields := strings.Fields(line)
+		if len(fields) == 2 && fields[1] == bundleName {
+			return fields[0], nil
 		}
 	}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3f865b6 and 72c1c46.

📒 Files selected for processing (8)
  • cmd/crc/cmd/bundle/bundle.go
  • cmd/crc/cmd/bundle/clear.go
  • cmd/crc/cmd/bundle/download.go
  • cmd/crc/cmd/bundle/list.go
  • cmd/crc/cmd/bundle/prune.go
  • cmd/crc/cmd/bundle/util.go
  • cmd/crc/cmd/root_test.go
  • pkg/crc/constants/constants.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • cmd/crc/cmd/bundle/prune.go
  • pkg/crc/constants/constants.go
  • cmd/crc/cmd/bundle/list.go
  • cmd/crc/cmd/root_test.go
🧰 Additional context used
🧬 Code graph analysis (3)
cmd/crc/cmd/bundle/download.go (5)
pkg/crc/config/settings.go (1)
  • GetPreset (147-149)
pkg/crc/constants/constants.go (5)
  • GetDefaultBundlePath (135-137)
  • GetDefaultBundleDownloadURL (139-145)
  • BundleName (88-111)
  • MachineCacheDir (128-128)
  • DefaultMirrorURL (33-33)
pkg/download/download.go (1)
  • InMemory (101-126)
pkg/crc/gpg/gpg.go (1)
  • GetVerifiedClearsignedMsgV3 (41-68)
pkg/crc/constants/keys.go (1)
  • RedHatReleaseKey (22-49)
cmd/crc/cmd/bundle/clear.go (2)
pkg/crc/constants/constants.go (1)
  • MachineCacheDir (128-128)
pkg/crc/logging/logging.go (1)
  • Infof (92-94)
cmd/crc/cmd/bundle/util.go (3)
pkg/crc/constants/constants.go (3)
  • DefaultMirrorURL (33-33)
  • BundleName (88-111)
  • MachineCacheDir (128-128)
pkg/crc/network/httpproxy/proxy.go (1)
  • HTTPTransport (257-264)
pkg/crc/logging/logging.go (1)
  • Debugf (124-126)
🔇 Additional comments (7)
cmd/crc/cmd/bundle/bundle.go (1)

8-22: LGTM!

The bundle command wiring is clean and correctly integrates the new subcommands. The updated description accurately reflects the expanded functionality.

cmd/crc/cmd/bundle/clear.go (1)

24-57: LGTM!

The runClear implementation correctly handles the deletion workflow with proper error tracking. The previous issue about silently swallowed deletion failures has been addressed—lastErr now captures failures, cleared is only set on successful deletions, and the function returns the last error encountered.

cmd/crc/cmd/bundle/download.go (2)

23-50: LGTM!

The command structure is well-designed with appropriate flags for --force and --preset. The preset parsing includes proper error handling, and the default preset is correctly retrieved from config when not specified.


52-126: Well-structured download workflow with proper verification.

The implementation correctly:

  • Handles both default and custom version downloads
  • Resolves partial versions (Major.Minor) to full versions
  • Verifies bundle signatures before download
  • Uses SHA256 integrity checking

The fallback behavior on version resolution failure (line 80) is reasonable—it warns and attempts with the original string, letting the download fail naturally if the URL is invalid.

cmd/crc/cmd/bundle/util.go (3)

20-93: Well-implemented version discovery with graceful fallback.

The function properly:

  • Uses proxy-aware HTTP transport with a reasonable timeout
  • Handles HTTP errors appropriately
  • Employs a primary regex with a simpler fallback for different HTML formats
  • Deduplicates and validates versions using semver
  • Returns versions sorted newest-first

95-127: Version resolution logic handles edge cases well.

The function correctly:

  • Passes through full versions unchanged
  • Passes through non-standard formats (tags, etc.) unchanged
  • Resolves Major.Minor to the latest patch version
  • Returns a clear error when no matching version is found

129-136: LGTM!

The previous review concern about cross-architecture support has been addressed. The function now accepts an arch parameter and correctly uses constants.BundleName(preset, version, arch) to construct the bundle path.

@sebrandon1
Copy link
Contributor Author

/retest-required

@albfan albfan moved this to Ready for review in Project planning: crc Jan 7, 2026
Address comments 1

Address comments; consts, split bundle commands into files
@sebrandon1 sebrandon1 force-pushed the add_bundle_download_cmd branch from 72c1c46 to 1b2454f Compare February 4, 2026 21:29
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@cmd/crc/cmd/bundle/download.go`:
- Around line 52-66: The runDownload function currently ignores extra positional
args; add a guard at the start of runDownload that checks args length and
returns an error when len(args) > 2 (allowed: [version] [architecture]) so
callers fail fast with a clear message; update the error return to use
fmt.Errorf or the package's preferred error/logging pattern and reference
runDownload and args in the message to make it obvious which call site and
parameters were invalid.
- Around line 77-84: The code currently swallows errors from
resolveOpenShiftVersion and continues with the original partial version; change
this to fail fast: when resolveOpenShiftVersion(preset, version) returns an
error, propagate or return that error instead of logging a warning and
continuing. Update the logic around resolveOpenShiftVersion, logging.Warnf, and
the subsequent version assignment so that resolve errors cause an early return
(or a wrapped error) so callers know resolution failed rather than proceeding
with the unresolved partial version.

In `@cmd/crc/cmd/bundle/prune.go`:
- Around line 45-68: The prune logic currently sorts bundleFiles globally and
removes everything beyond the newest keep, but you must instead group bundles by
major.minor (and arch/preset if applicable) and prune within each group; update
the code around bundleFiles, sort.Slice and the removal loop to (1) parse each
file.Name() (or bundle metadata via file.Info()) to extract semver major.minor
and arch, (2) build a map[groupKey][]os.FileInfo (or []fs.File) where groupKey =
major.minor + ":" + arch, (3) for each group sort by semantic version (or
ModTime if version not parseable) to get newest-first, (4) keep the first keep
entries per group and call os.RemoveAll(filepath.Join(cacheDir, file.Name()))
only for the rest, and ensure errors from Info()/version parsing are handled
(treat unparsable entries as oldest so they get pruned last).

Comment on lines +52 to +66
func runDownload(args []string, preset crcPreset.Preset, force bool) error {
// Disk space check (simple check for ~10GB free)
// This is a basic check, more robust checking would require syscall/windows specific implementations
// We skip this for now to avoid adding heavy OS-specific deps, assuming user manages disk space or download fails naturally.

// If no args, use default bundle path
if len(args) == 0 {
defaultBundlePath := constants.GetDefaultBundlePath(preset)
if !force {
if _, err := os.Stat(defaultBundlePath); err == nil {
logging.Infof("Bundle %s already exists. Use --force to overwrite.", defaultBundlePath)
return nil
}
}

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reject extra positional args to avoid silent ignores.
Right now, additional args beyond [version] [architecture] are ignored. Consider failing fast with a clear error.

✅ Proposed fix
 func runDownload(args []string, preset crcPreset.Preset, force bool) error {
+	if len(args) > 2 {
+		return fmt.Errorf("too many arguments: expected at most 2 (version, architecture), got %d", len(args))
+	}
 	// Disk space check (simple check for ~10GB free)
 	// This is a basic check, more robust checking would require syscall/windows specific implementations
 	// We skip this for now to avoid adding heavy OS-specific deps, assuming user manages disk space or download fails naturally.
🤖 Prompt for AI Agents
In `@cmd/crc/cmd/bundle/download.go` around lines 52 - 66, The runDownload
function currently ignores extra positional args; add a guard at the start of
runDownload that checks args length and returns an error when len(args) > 2
(allowed: [version] [architecture]) so callers fail fast with a clear message;
update the error return to use fmt.Errorf or the package's preferred
error/logging pattern and reference runDownload and args in the message to make
it obvious which call site and parameters were invalid.

Comment on lines +77 to +84
// Check if version is partial (Major.Minor) and resolve it if necessary
resolvedVersion, err := resolveOpenShiftVersion(preset, version)
if err != nil {
logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", version, err)
} else if resolvedVersion != version {
logging.Debugf("Resolved version %s to %s", version, resolvedVersion)
version = resolvedVersion
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when partial-version resolution fails.
For a major.minor input, continuing with an unresolved version will usually 404 or fail signature validation, which is less actionable than a clear resolution error.

✅ Proposed fix
 	resolvedVersion, err := resolveOpenShiftVersion(preset, version)
 	if err != nil {
-		logging.Warnf("Could not resolve version %s: %v. Trying with original version string.", version, err)
+		return fmt.Errorf("failed to resolve version %s: %w", version, err)
 	} else if resolvedVersion != version {
 		logging.Debugf("Resolved version %s to %s", version, resolvedVersion)
 		version = resolvedVersion
 	}
🤖 Prompt for AI Agents
In `@cmd/crc/cmd/bundle/download.go` around lines 77 - 84, The code currently
swallows errors from resolveOpenShiftVersion and continues with the original
partial version; change this to fail fast: when resolveOpenShiftVersion(preset,
version) returns an error, propagate or return that error instead of logging a
warning and continuing. Update the logic around resolveOpenShiftVersion,
logging.Warnf, and the subsequent version assignment so that resolve errors
cause an early return (or a wrapped error) so callers know resolution failed
rather than proceeding with the unresolved partial version.

Comment on lines +45 to +68
if len(bundleFiles) <= keep {
logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep)
return nil
}

// Sort by modification time, newest first
sort.Slice(bundleFiles, func(i, j int) bool {
infoI, errI := bundleFiles[i].Info()
infoJ, errJ := bundleFiles[j].Info()
if errI != nil || errJ != nil {
// If we can't get info, treat as oldest (sort to end for pruning)
return errJ != nil && errI == nil
}
return infoI.ModTime().After(infoJ.ModTime())
})

for i := keep; i < len(bundleFiles); i++ {
file := bundleFiles[i]
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Pruning old bundle: %s", file.Name())
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prune logic keeps only N total bundles, not N per major.minor.
The current mtime-based pruning deletes everything beyond the newest keep bundles globally, which conflicts with the requirement to keep the latest two patch versions per major.minor (and can unintentionally delete other arch/preset bundles). Consider grouping by major.minor (and arch) and pruning within each group based on version.

🔧 Proposed fix (group by version/arch and prune within each group)
@@
-	if len(bundleFiles) <= keep {
+	if len(bundleFiles) <= keep {
 		logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep)
 		return nil
 	}
 
-	// Sort by modification time, newest first
-	sort.Slice(bundleFiles, func(i, j int) bool {
-		infoI, errI := bundleFiles[i].Info()
-		infoJ, errJ := bundleFiles[j].Info()
-		if errI != nil || errJ != nil {
-			// If we can't get info, treat as oldest (sort to end for pruning)
-			return errJ != nil && errI == nil
-		}
-		return infoI.ModTime().After(infoJ.ModTime())
-	})
-
-	for i := keep; i < len(bundleFiles); i++ {
-		file := bundleFiles[i]
-		filePath := filepath.Join(cacheDir, file.Name())
-		logging.Infof("Pruning old bundle: %s", file.Name())
-		if err := os.RemoveAll(filePath); err != nil {
-			logging.Errorf("Failed to remove %s: %v", filePath, err)
-		}
-	}
+	type bundleInfo struct {
+		entry os.DirEntry
+		major int
+		minor int
+		patch int
+		arch  string
+	}
+
+	groups := map[string][]bundleInfo{}
+	for _, file := range bundleFiles {
+		info, ok := parseBundleInfo(file.Name())
+		if !ok {
+			logging.Warnf("Skipping bundle with unrecognized version format: %s", file.Name())
+			continue
+		}
+		info.entry = file
+		key := fmt.Sprintf("%d.%d/%s", info.major, info.minor, info.arch)
+		groups[key] = append(groups[key], info)
+	}
+
+	for _, group := range groups {
+		sort.Slice(group, func(i, j int) bool {
+			return group[i].patch > group[j].patch
+		})
+		for i := keep; i < len(group); i++ {
+			file := group[i].entry
+			filePath := filepath.Join(cacheDir, file.Name())
+			logging.Infof("Pruning old bundle: %s", file.Name())
+			if err := os.RemoveAll(filePath); err != nil {
+				logging.Errorf("Failed to remove %s: %v", filePath, err)
+			}
+		}
+	}
 
 	return nil
 }
+
+func parseBundleInfo(name string) (bundleInfo, bool) {
+	base := strings.TrimSuffix(name, ".crcbundle")
+	parts := strings.Split(base, "_")
+	if len(parts) < 3 {
+		return bundleInfo{}, false
+	}
+	versionStr := parts[len(parts)-2]
+	arch := parts[len(parts)-1]
+	verParts := strings.Split(versionStr, ".")
+	if len(verParts) != 3 {
+		return bundleInfo{}, false
+	}
+	major, err1 := strconv.Atoi(verParts[0])
+	minor, err2 := strconv.Atoi(verParts[1])
+	patch, err3 := strconv.Atoi(verParts[2])
+	if err1 != nil || err2 != nil || err3 != nil {
+		return bundleInfo{}, false
+	}
+	return bundleInfo{major: major, minor: minor, patch: patch, arch: arch}, true
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if len(bundleFiles) <= keep {
logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep)
return nil
}
// Sort by modification time, newest first
sort.Slice(bundleFiles, func(i, j int) bool {
infoI, errI := bundleFiles[i].Info()
infoJ, errJ := bundleFiles[j].Info()
if errI != nil || errJ != nil {
// If we can't get info, treat as oldest (sort to end for pruning)
return errJ != nil && errI == nil
}
return infoI.ModTime().After(infoJ.ModTime())
})
for i := keep; i < len(bundleFiles); i++ {
file := bundleFiles[i]
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Pruning old bundle: %s", file.Name())
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
}
}
if len(bundleFiles) <= keep {
logging.Infof("Nothing to prune (found %d bundles, keeping %d)", len(bundleFiles), keep)
return nil
}
type bundleInfo struct {
entry os.DirEntry
major int
minor int
patch int
arch string
}
groups := map[string][]bundleInfo{}
for _, file := range bundleFiles {
info, ok := parseBundleInfo(file.Name())
if !ok {
logging.Warnf("Skipping bundle with unrecognized version format: %s", file.Name())
continue
}
info.entry = file
key := fmt.Sprintf("%d.%d/%s", info.major, info.minor, info.arch)
groups[key] = append(groups[key], info)
}
for _, group := range groups {
sort.Slice(group, func(i, j int) bool {
return group[i].patch > group[j].patch
})
for i := keep; i < len(group); i++ {
file := group[i].entry
filePath := filepath.Join(cacheDir, file.Name())
logging.Infof("Pruning old bundle: %s", file.Name())
if err := os.RemoveAll(filePath); err != nil {
logging.Errorf("Failed to remove %s: %v", filePath, err)
}
}
}
return nil
}
func parseBundleInfo(name string) (bundleInfo, bool) {
base := strings.TrimSuffix(name, ".crcbundle")
parts := strings.Split(base, "_")
if len(parts) < 3 {
return bundleInfo{}, false
}
versionStr := parts[len(parts)-2]
arch := parts[len(parts)-1]
verParts := strings.Split(versionStr, ".")
if len(verParts) != 3 {
return bundleInfo{}, false
}
major, err1 := strconv.Atoi(verParts[0])
minor, err2 := strconv.Atoi(verParts[1])
patch, err3 := strconv.Atoi(verParts[2])
if err1 != nil || err2 != nil || err3 != nil {
return bundleInfo{}, false
}
return bundleInfo{major: major, minor: minor, patch: patch, arch: arch}, true
}
🤖 Prompt for AI Agents
In `@cmd/crc/cmd/bundle/prune.go` around lines 45 - 68, The prune logic currently
sorts bundleFiles globally and removes everything beyond the newest keep, but
you must instead group bundles by major.minor (and arch/preset if applicable)
and prune within each group; update the code around bundleFiles, sort.Slice and
the removal loop to (1) parse each file.Name() (or bundle metadata via
file.Info()) to extract semver major.minor and arch, (2) build a
map[groupKey][]os.FileInfo (or []fs.File) where groupKey = major.minor + ":" +
arch, (3) for each group sort by semantic version (or ModTime if version not
parseable) to get newest-first, (4) keep the first keep entries per group and
call os.RemoveAll(filepath.Join(cacheDir, file.Name())) only for the rest, and
ensure errors from Info()/version parsing are handled (treat unparsable entries
as oldest so they get pruned last).

@sebrandon1
Copy link
Contributor Author

@albfan @anjannath @evidolob - Rebased this PR on the latest main branch. Currently monitoring CI checks - builds are passing, and the e2e-microshift tests have completed successfully. Waiting on remaining e2e/integration tests to complete.

@openshift-ci
Copy link

openshift-ci bot commented Feb 4, 2026

@sebrandon1: The following tests failed, say /retest to rerun all failed tests or /retest-required to rerun all mandatory failed tests:

Test name Commit Details Required Rerun command
ci/prow/security 1b2454f link false /test security
ci/prow/integration-crc 1b2454f link true /test integration-crc

Full PR test history. Your PR dashboard.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. I understand the commands that are listed here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Ready for review

Development

Successfully merging this pull request may close these issues.

3 participants